Перегрузка методов

Часто бывает удобно, чтобы методы, реализующие один и тот же алгоритм для различных типов данных, имели одно и то же имя. Использование нескольких методов с одним и тем же именем, но различными типами параметров называется перегрузкой методов.

Компилятор определяет, какой именно метод требуется вызвать, по типу фактических параметров. Этот процесс называется разрешением (resolution) перегрузки. Тип возвращаемого методом значения в разрешении не участвует. Механизм разрешения основан на достаточно сложном наборе правил, смысл которых сводится к тому, чтобы использовать метод с наиболее подходящими аргументами и выдать сообщение, если такой не найдется. Допустим, имеется четыре варианта метода, определяющего наибольшее значение:


// Возвращает наибольшее из двух целых:
int max( int a, int b )
// Возвращает наибольшее из трех целых:
int max( int a, int b, int c )
// Возвращает наибольшее из первого параметра и длины второго:
int max ( int a, string b )
// Возвращает наибольшее из второго параметра и длины первого:
int max ( string b, int a )
...
Console.WriteLine( max( 1, 2 ) );
Console.WriteLine( max( 1, 2, 3 ) );
Console.WriteLine( max( 1, "2" ) );
Console.WriteLine( max( "1", 2 ) );


При вызове метода max компилятор выбирает вариант метода, соответствующий типу передаваемых в метод аргументов (в приведенном примере будут последовательно вызваны все четыре варианта метода).

Если точного соответствия не найдено, выполняются неявные преобразования типов в соответствии с общими правилами. Если преобразование невозможно, выдается сообщение об ошибке. Если соответствие на одном и том же этапе может быть получено более чем одним способом, выбирается вариант, содержащий меньшее количество и длину преобразований. Если существует несколько вариантов, из которых невозможно выбрать лучший, выдается сообщение об ошибке.

Перегруженные методы имеют одно имя, но должны различаться параметрами, точнее их типами и способами передачи ( out или ref ). Например, методы, заголовки которых приведены ниже, имеют различные сигнатуры и считаются перегруженными:

int max( int a, int b )
int max( int a, ref int b )

Перегрузка широко используется в классах библиотеки .NET. Например, в стандартном классе Console метод WriteLine перегружен 19 раз для вывода величин разных типов. Пример класса с перегруженными методами приведен в конце этой лекции (листинг 7.3).

Рекурсивные методы

Рекурсивным называется метод, который вызывает сам себя. Такая рекурсия называется прямой. Существует еще косвенная рекурсия, когда два или более метода вызывают друг друга. Если метод вызывает себя, в стеке создается копия значений его параметров, как и при вызове обычного метода, после чего управление передается первому исполняемому оператору метода. При повторном вызове этот процесс повторяется.

Ясно, что для завершения вычислений каждый рекурсивный метод должен содержать хотя бы одну нерекурсивную ветвь алгоритма, заканчивающуюся оператором возврата. При завершении метода соответствующая часть стека освобождается, и управление передается вызывающему методу, выполнение которого продолжается с точки, следующей за рекурсивным вызовом.

Классическим примером рекурсивной функции является функция вычисления факториала (это не означает, что факториал следует вычислять именно так). Для того чтобы получить значение факториала числа n, требуется умножить на n факториал числа (n – 1 ). Известно также, что 0!= 1 и 1 != 1.


long fact( long n ) {
    if ( n == 0 || n == 1 ) return 1; // нерекурсивная ветвь
    return ( n * fact( n – 1 ) );     // рекурсивная ветвь
}

То же самое можно записать короче:


long fact( long n ) {
    return ( n > 1 ) ? n * fact( n – 1 ) : 1;
}


Рекурсивные методы чаще всего применяют для компактной реализации рекурсивных алгоритмов, а также для работы со структурами данных, описанными рекурсивно, например, с двоичными деревьями.

К достоинствам рекурсии можно отнести компактность записи, к недостаткам — расход времени и памяти на повторные вызовы метода и передачу ему копий параметров, а главное, опасность переполнения стека.

Методы с переменным количеством аргументов

Иногда бывает удобно создать метод, в который можно передавать разное количество аргументов. Язык C# предоставляет такую возможность с помощью ключевого слова params. Параметр, помеченный этим ключевым словом, размещается в списке параметров последним и обозначает массив заданного типа неопределенной длины, например:

public int Calculate( int a, out int c, params int[] d ) …

В этот метод можно передать три и более параметров. Внутри метода к параметрам, начиная с третьего, обращаются как к обычным элементам массива. Количество элементов массива получают с помощью его свойства Length. В качестве примера рассмотрим метод вычисления среднего значения элементов массива (листинг 7.1).


using System;

namespace Example_1
{
    class Program
    {
        public static double Average( params int[] a )
        {
            if ( a.Length == 0 ) 
                throw new Exception( "Недостаточно аргументов в вызове метода" );

            double av = 0;
            foreach ( int elem in a ) av += elem;
            return av / a.Length;
        }
        static void Main(string[] args)
        {
            try
            {
                int[] a = { 10, 20, 30 };
                Console.WriteLine( Average( a ) );            // 1
                int[] b = { -11, -4, 12, 14, 32, -1, 28 };
                Console.WriteLine( Average( b ) );            // 2
                short z = 1, e = 12;
                byte v = 100;
                Console.WriteLine( Average( z, e, v ) );      // 3
                Console.WriteLine(Average(3, 4, 5));          
                Console.WriteLine( Average() );               // 4
            }
            catch( Exception e )
            {
                Console.WriteLine( e.Message );
                return;
            }
            return;
        }
    }
}

Листинг 1. Метод с переменным числом параметров


Результат работы программы:


Параметр-массив может быть только один и должен располагаться последним в списке. Соответствующие ему аргументы должны иметь типы, для которых возможно неявное преобразование к типу массива.

Метод Main

Метод, которому передается управление после запуска программы, должен иметь имя Мain и быть статическим. Он может принимать параметры из внешнего окружения и возвращать значение в вызвавшую среду. Предусматривается два варианта метода — с параметрами и без параметров:


long fact( long n ) {
    if ( n == 0 || n == 1 ) return 1; // нерекурсивная ветвь
    return ( n * fact( n – 1 ) );     // рекурсивная ветвь
}

То же самое можно записать короче:


// без параметров: 
static тип  Main() { ... }
static void Main() { ... }
// с параметрами:
static тип  Main( string[] args ) { /* ... */ }
static void Main( string[] args ) { /* ... */ }

Параметры, разделяемые пробелами, задаются при запуске программы из командной строки после имени исполняемого файла программы. Они передаются в массив args.

Если метод возвращает значение, оно должно быть целого типа, если не возвращает, он должен описываться как void. Ненулевое значение обычно означает аварийное завершение. Возвращаемое значение анализируется в командном файле, из которого запускается программа. Обычно это делается для того, чтобы можно было принять решение, выполнять ли командный файл дальше. В листинге 2 приведен пример метода Main, который выводит свои аргументы и ожидает нажатия любой клавиши.


using System;
namespace Example_2
{
    class Program
    {
        static void Main(string[] args)
        {
            foreach (string arg in args)
                Console.WriteLine(arg);
            Console.ReadKey();
        }
    }
}

Листинг 2. Параметры метода Main


Индексаторы

Индексатор представляет собой разновидность свойства. Если у класса есть скрытое поле, представляющее собой массив, то с помощью индексатора можно обратиться к элементу этого массива, используя имя объекта и номер элемента массива в квадратных скобках. Иными словами, индексатор — это такой "умный" индекс для объектов.

Синтаксис индексатора аналогичен синтаксису свойства:


атрибуты спецификаторы тип this [ список_параметров ]
{
    get код_доступа
    set код_доступа
}

Индексаторы чаще всего объявляются со спецификатором public, поскольку они входят в интерфейс объекта. Атрибуты и спецификаторы могут отсутствовать.

Код доступа представляет собой блоки операторов, которые выполняются при получении ( get ) или установке значения ( set ) элемента массива. Может отсутствовать либо часть get, либо set, но не обе одновременно. Если отсутствует часть set, индексатор доступен только для чтения (read-only), если отсутствует часть get, индексатор доступен только для записи (write-only).

Список параметров содержит одно или несколько описаний индексов, по которым выполняется доступ к элементу. Чаще всего используется один индекс целого типа.

Индексаторы в основном применяются для создания специализированных массивов, на работу с которыми накладываются какие-либо ограничения. Примеры работы с индексатором приведён ниже.


using System;
namespace Vector
{
    class Vector
    {
        int[] v;
        int size;
        public int Size { get { return size; } }
        //Индексатор для доступа к элементам массива v.
        public int this[int j]
        {
            get 
            {
                if (!(j >= 0 && j < size)) throw new IndexOutOfRangeException("Неверный индекс!");
                return v[j];                 
            }
            set 
            {
                if (!(j >= 0 && j < size)) throw new IndexOutOfRangeException("Неверный индекс!");
                v[j] = value; 
            }
        }
        public Vector(int n = 5)
        {
            size = n;
            v = new int[size];
        }
        public override string ToString()
        {
            string s = "";
            foreach (int x in v) s += x + ", ";
            return s;
        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            try 
            { 
                Vector a = new Vector(3);
                for (int i = 0; i < 8; i++) 
                {
                    Console.Write("[{0}] = ", i);
                    //Запись значения через индексатор.
                    a[i] = int.Parse(Console.ReadLine());
                }
                Console.WriteLine(a.ToString());
            }
            catch (IndexOutOfRangeException e) 
            {
                Console.WriteLine(e.Message);            
            }
        }
    }
}



Результат работы программы:


Язык C# допускает использование многомерных индексаторов. Они описываются аналогично обычным и применяются в основном для контроля за занесением данных в многомерные массивы и выборке данных из многомерных массивов, оформленных в виде классов.

Операции класса

Класс — это полноценный тип данных, поэтому программист может определить для него собственные операции. Вы уже сталкивались с перегрузкой (т.е. переопределением) операций: например, знак " + " для арифметических типов означает сложение, а для строк — склеивание. Операции перегружают в основном для классов, с помощью которых задают какие-либо математические понятия, т.е. тогда, когда знаки операции имеют общепринятую семантику. Можно задать операцию сложения для класса Frac, предназначенного для работы с простыми дробями. Примеры перегрузки приведены именно для этого класса, полное описание которого дано в конце лекции.

Унарные операции

Можно определять в классе следующие унарные операции:

+ - ! ~ ++ -- true false

Синтаксис объявителя унарной операции:

тип operator унарная_операция ( параметр )

Примеры заголовков унарных операций:


public static int operator ++( MyObject m )
public static MyObject operator --( MyObject m )
public static bool operator true( MyObject m )

Параметр, передаваемый в операцию, должен иметь тип класса, для которого она определяется. Операция должна возвращать:

• для операций +, -, ! и ~ величину любого типа;

• для операций ++ и -- величину типа класса, для которого она определяется;

• для операций true и false величину типа bool.

Операции не должны изменять значение передаваемого им операнда. Операция, возвращающая величину типа класса, для которого она определяется, должна создать новый объект этого класса, выполнить с ним необходимые действия и передать его в качестве результата.

Примечание

Префиксный и постфиксный инкремент не различаются (для них может существовать только одна реализация, которая вызывается в обоих случаях).

Пример перегрузки операции инкремента (примем, что увеличение простой дроби на единицу должно увеличивать её числитель на единицу):


class Frac
        {
        public static Frac operator ++(Frac a)
        {
            return new Frac(1) + a;
        }
  	…
	}
 	…
            Frac x = new Frac(1,2);
            ++x; x++; 



Бинарные операции

Можно определять в классе следующие бинарные операции:

+ - * / % & | ^ << >> == != > < >= <=

Синтаксис объявителя бинарной операции:

тип operator бинарная_операция (параметр1, параметр2)

Примеры заголовков бинарных операций:


public static MyObject operator +  ( MyObject m1, MyObject m2 )
public static bool     operator == ( MyObject m1, MyObject m2 )

Хотя бы один параметр, передаваемый в операцию, должен иметь тип класса, для которого она определяется. Операция может возвращать величину любого типа.

Операции == и !=, > и <, >= и <= определяются только парами и обычно возвращают логическое значение. Чаще всего в классе определяют операции сравнения на равенство и неравенство для того, чтобы обеспечить сравнение объектов, а не их ссылок, как определено по умолчанию для ссылочных типов.

Сложные операции присваивания (например, += ) определять не требуется, да это и невозможно. При выполнении такой операции автоматически вызываются сначала операция сложения, а потом присваивания.

Пример описания бинарных операций для класса Frac Сложение двух дробей:


public static Frac operator +(Frac a, Frac b)
            {
                int n = a.num * b.denom + b.num * a.denom;
                int d = a.denom * b.denom;
                return new Frac(n, d);
            }
…
            Frac a = new Frac(1, 5);
            Frac b = new Frac(2, 7);
            Frac d = b + a;
            d = a + 2;
            d = a + d;
…


Cложение с константой:


public static Frac operator +(int a, Frac b)
        {           
            return new Frac(a) + b;
        }
        public static Frac operator +(Frac a, int b)
        {
            return new Frac(b) + a;
        }


Операции преобразования типа

Операции преобразования типа обеспечивают возможность явного и неявного преобразования между пользовательскими типами данных. Синтаксис объявителя операции преобразования типа:

implicit operator тип ( параметр ) // неявное преобразование
explicit operator тип ( параметр ) // явное преобразование

Эти операции выполняют преобразование из типа параметра в тип, указанный в заголовке операции. Одним из этих типов должен быть класс, для которого определяется операция. Таким образом, операции выполняют преобразование либо типа класса к другому типу, либо наоборот. Преобразуемые типы не должны быть связаны отношениями наследования. Примеры операций преобразования типа для класса Frac, описанного ранее:


            public static implicit operator double(Frac m)
            {
                return (double)m.num / m.denom;
            }
            public static explicit operator Frac(double n)
            {
                double v1 = 100000000 * n;
                double v2 = 10000000 * n;

                return new Frac((int)(v1 - v2), 90000000);
            }


Ниже приведены примеры использования этих преобразований в программе.

Не надо искать в них смысл, они просто иллюстрируют синтаксис:


           Frac z = new Frac( 12, 5 );
            double r = z; // неявное преобразование
            Frac c = ( Frac ) 0.75; // явное преобразование



Неявное преобразование выполняется автоматически:

• при присваивании объекта переменной целевого типа, как в примере;

• при использовании объекта в выражении, содержащем переменные целевого типа;

• при передаче объекта в метод на место параметра целевого типа;

• при явном приведении типа.

Явное преобразование выполняется при использовании операции приведения типа.

Все операции класса должны иметь разные сигнатуры. В отличие от других видов методов, для операций преобразования тип возвращаемого значения включается в сигнатуру, иначе нельзя было бы определять варианты преобразования данного типа в несколько других. Ключевые слова implicit и explicit в сигнатуру не включаются, следовательно, для одного и того же преобразования нельзя определить одновременно явную и неявную версию.

Неявное преобразование следует определять так, чтобы при его выполнении не возникала потеря точности и не генерировались исключения. Если эти ситуации возможны, преобразование следует описать как явное.

Пример класса с перегруженными методами и операциями


using System;

namespace Example_3
{
    class Frac
    {
        int num;
        int denom;
        public Frac(int n = 0, int d = 1)
        {
            if (d == 0) throw new Exception("Знаменатель равен нулю!");
            this.num = n;
            this.denom = d;
            Reduce();
        }
        public override string ToString()
        {
            string s = string.Format("{0}/{1}", num, denom);
            return s;
        }
        public Frac()
        {
            this.num = 0;
            this.denom = 1;
            Reduce();
        }
        public Frac(string s = "0/1")
        {

            int n = s.IndexOf('/');
            if (n == -1)
            {
                this.num = int.Parse(s);
                this.denom = 1;
            }
            else
            {
                this.num = int.Parse(s.Substring(0, n));
                this.denom = int.Parse(s.Substring(n + 1));
            }
            if (denom == 0) throw new Exception("Знаменатель равен нулю!");
            Reduce();
        }
        public static Frac operator ++(Frac a)
        {
            return new Frac(1) + a;
        }
        public static Frac operator +(Frac a, Frac b)
        {
            int n = a.num * b.denom + b.num * a.denom;
            int d = a.denom * b.denom;
            return new Frac(n, d);
        }
        public static Frac operator -(Frac a, Frac b)
        {
            int n = a.num * b.denom - b.num * a.denom;
            int d = a.denom * b.denom;
            return new Frac(n, d);
        }
        public static Frac operator +(int a, Frac b)
        {           
            return new Frac(a) + b;
        }
        public static Frac operator +(Frac a, int b)
        {
            return new Frac(b) + a;
        }
        public static implicit operator double(Frac m)
        {
            return (double)m.num / m.denom;
        }
        public static explicit operator Frac(double n)
        {
            double v1 = 100000000 * n;
            double v2 = 10000000 * n;

            return new Frac((int)(v1 - v2), 90000000);
        }

        static int nod(int a, int b)
        {
            int t;
            a = Math.Abs(a);
            b = Math.Abs(b);
            if (a > b) { t = a; a = b; b = t; }
            while (a != 0 & a != b)
            {
                if (a <= b) { b = b % a; }
                else { t = a; a = b; b = t; }
            }
            return b;
        }
        void Reduce()
        {
            int t = nod(this.num, this.denom);
            this.num /= t;
            this.denom /= t;
        }

    }
    class Program
    {
        static void Main(string[] args)
        {
            try 
            {
                Frac a = new Frac(1, 2);
                Frac b = new Frac(1, 2);
                a = a - b;
                Console.WriteLine("a = " + a);
                Frac d = 1 + a;
                Console.WriteLine("d = " + d);
                d = a + 2;
                Console.WriteLine("d = " + d);
                d = a + d;
                Console.WriteLine("d = " + d);
                Frac z = new Frac(12, 5);
                double r = z;
                Frac c = (Frac)0.75;
                Console.WriteLine("r = " + r);
                Console.WriteLine("c = " + c);
                Frac w = new Frac(1);
                Console.WriteLine("w = " + ++w);
                w = w + 1;
                Console.WriteLine("w = " + w);
                w = 1 + w;
                Console.WriteLine("w = " + w);
                /*Frac b = new Frac("3/5");
                Console.WriteLine(b.ToString());
                Frac c = a + b;
                Console.WriteLine(c.ToString());
                Frac x = new Frac(1,2);
                for (int i = 0; i < 5; i++) { Console.WriteLine(x); ++x; x++; }*/
            }
            catch (Frac.DenMistake e) 
            {
                Console.WriteLine(e.Message);
            }
        }
    }
}


Результат работы программы:


Деструкторы

В C# существует специальный вид метода, называемый деструктором. Он вызывается сборщиком мусора непосредственно перед удалением объекта из памяти. В деструкторе описываются действия, гарантирующие корректность последующего удаления объекта, например, проверяется, все ли ресурсы, используемые объектом, освобождены (файлы закрыты, удаленное соединение разорвано и т. п.)

Синтаксис деструктора:

[ атрибуты ] [ extern ] ~имя_класса()
тело

Как видно из определения, деструктор не имеет параметров, не возвращает значения и не требует указания спецификаторов доступа. Его имя совпадает с именем класса и предваряется тильдой ( ), символизирующей обратные по отношению к конструктору действия. Тело деструктора представляет собой блок или просто точку с запятой, если деструктор определен как внешний ( extern ).

Сборщик мусора удаляет объекты, на которые нет ссылок. Он работает в соответствии со своей внутренней стратегией в неизвестные для программиста моменты времени. Поскольку деструктор вызывается сборщиком мусора, невозможно гарантировать, что деструктор будет обязательно вызван в процессе работы программы. Следовательно, его лучше использовать только для гарантии освобождения ресурсов, а "штатное" освобождение выполнять в другом месте программы.

Вложенные типы

В классе можно определять типы данных, внутренние по отношению к классу. Так определяются вспомогательные типы, которые используются только содержащим их классом. Механизм вложенных типов позволяет скрыть ненужные детали и более полно реализовать принцип инкапсуляции. Непосредственный доступ извне к такому классу невозможен (имеется в виду доступ по имени без уточнения). Для вложенных типов можно использовать те же спецификаторы, что и для полей класса.

Например, введем в наш класс Frac вспомогательный класс DenMistake. Это класс объектов, сигнализирующих об исключительной ситуации, возникающей в операциях на объектах класса Frac. Эта исключительная ситуация возникает, когда числитель дроби становится равен нулю. Объекты этого класса без "хозяина" бесполезны, поэтому его можно определить как внутренний:


    class Frac
    {
        public class DenMistake : Exception
        {
            public DenMistake(string s)
                : base(s)
            { }
        }
        int num;
        int denom;
        public Frac(int n = 0, int d = 1)
        {
            if (d == 0) throw new DenMistake("Знаменатель равен нулю!");
            this.num = n;
            this.denom = d;
            Reduce();
        }
     …	
    }


Помимо классов, вложенными могут быть и другие типы данных: интерфейсы, структуры и перечисления. Мы рассмотрим их позже.

Вопросы и задания для самостоятельной работы студента

1. Что такое индексатор, для каких классов он применяется?

2. Может ли статический конструктор инициализировать поля экземпляра?

3. Зачем нужна перегрузка методов и операций?

4. Опишите способы перегрузки операций.

5. Для каких классов имеет смысл использовать перегруженные операции?

6. Какой тип имеет параметр унарной операции класса?

7. Можно ли перегрузить операции простого и сложного присваивания?

8. Как передать программе параметры из внешнего окружения?